Skip to content

Conversation

emmanuel-ferdman
Copy link
Contributor

@emmanuel-ferdman emmanuel-ferdman commented Oct 4, 2025

Type of Changes

Type
βœ“ πŸ› Bug fix

Description

This PR does:

This PR moves object dunder methods from FunctionModel to ObjectModel as Unknown placeholders, making them available to all object types.

Fixes #2742 #2741.

Copy link

codecov bot commented Oct 4, 2025

Codecov Report

❌ Patch coverage is 93.02326% with 3 lines in your changes missing coverage. Please review.
βœ… Project coverage is 93.33%. Comparing base (2ce47a2) to head (c4f01a1).

Files with missing lines Patch % Lines
astroid/raw_building.py 25.00% 3 Missing ⚠️

❌ Your patch check has failed because the patch coverage (93.02%) is below the target coverage (100.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #2847      +/-   ##
==========================================
- Coverage   93.37%   93.33%   -0.04%     
==========================================
  Files          92       92              
  Lines       11150    11168      +18     
==========================================
+ Hits        10411    10424      +13     
- Misses        739      744       +5     
Flag Coverage Ξ”
linux 93.20% <93.02%> (-0.04%) ⬇️
pypy 93.33% <93.02%> (-0.04%) ⬇️
windows 93.32% <93.02%> (-0.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Ξ”
astroid/bases.py 89.74% <100.00%> (+0.05%) ⬆️
astroid/interpreter/objectmodel.py 96.33% <100.00%> (+0.06%) ⬆️
astroid/nodes/scoped_nodes/scoped_nodes.py 93.61% <100.00%> (+0.02%) ⬆️
astroid/raw_building.py 93.98% <25.00%> (-0.60%) ⬇️

... and 1 file with indirect coverage changes

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Pierre-Sassoulas Pierre-Sassoulas added the Enhancement ✨ Improvement to a component label Oct 5, 2025
@Pierre-Sassoulas Pierre-Sassoulas added this to the 4.1.0 milestone Oct 5, 2025
Copy link
Collaborator

@DanielNoord DanielNoord left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As can be seen from the docs you link, most of these should live on ObjectModel instead as they come from object.

We tried to do so before in #1519 but failed. Perhaps you want to have another look at that PR and see if you can fix the issue we faced there?

@emmanuel-ferdman
Copy link
Contributor Author

@DanielNoord Thanks! I’ll take a look at this. From that thread, it seems you were close to a solution, except for one test case that didn’t pass. Do you remember which test it was?

@DanielNoord
Copy link
Collaborator

@emmanuel-ferdman I believe it was an issue in the numpy brain, which overwrites __eq__. That overwrite doesn't work well with the changes to ObjectModel.

@emmanuel-ferdman
Copy link
Contributor Author

@DanielNoord I've put together an initial solution for moving object dunders from FunctionModel to ObjectModel. I've tested it against both the astroid test suite and the pylint test suite (all passing). Since I'm still learning the codebase, I'd appreciate any guidance on whether this approach is correct.

The solution uses a placeholder pattern: ObjectModel provides Unknown placeholders for all 24 object dunders, and the lookup logic skips these placeholders to find actual implementations.
Key changes:

  • ObjectModel now has all object dunders as Unknown placeholders that act as fallbacks when no actual implementation exists
  • Lookup logic in BaseInstance.getattr() and ClassDef.getattr() skips Unknown placeholders and continues searching
  • Builtin dunders (from the builtins module) return Uninferable instead of raising InferenceError since we can't infer their result without executing C code
  • Special case for __hash__ = None to ensure unhashable types (list, dict, set) properly override object's __hash__
  • Enhanced type.__new__() validation to raise descriptive InferenceError instead of silently returning None

How it works:

  • When a class overrides a dunder: lookup finds the actual implementation and returns the bound method
  • When a class doesn't override: lookup finds the Unknown placeholder, skips it, continues searching, eventually returns the placeholder which yields Uninferable when called
  • For unhashable builtin types (list, dict, set): __hash__ is explicitly set to None in locals.

Thanks for any feedback πŸ™Œ

Copy link
Collaborator

@DanielNoord DanielNoord left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is amazing, very well written PR and a nice set of tests.

Well done on getting this to pass all tests. I have left some comments, but would really like to help you push this over the line :)

Comment on lines 1621 to 1630
# Builtin dunder methods have empty bodies, return Uninferable.
if (
self.root().qname() == "builtins"
and self.name.startswith("__")
and self.name.endswith("__")
and self.parent
and self.parent.__class__.__name__ == "ClassDef"
):
yield util.Uninferable
return
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I sort of understand this change, but don't understand why we need to special case it like this.

Can we replace the checks with a look up in special_attributes? Or doesn't that work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this case because infer_call_result() has to decide what a call returns, and for built-in dunder methods there is no Python body to inspect (since they're implemented in C). If we tried to parse return nodes we would either fail or produce incorrect results.

special_attributes only tells us that an attribute exists (a placeholder), not whether there is a Python body to infer a return value from. Therefore it cannot replace the empty-body/builtins check used here.

But on second thought, I simplified the case by explicitly checking len(self.body) == 0.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be okay if we always did this for len(self.body) == 0? Is there a function where len(self.body) == 0 that is inferable? Or is as ... or pass as body also len(self.body) == 0?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can't simplify to just len(self.body) == 0 because both builtin dunders (__eq__, __hash__) and non-dunder builtins (getattr, len, print) have empty bodies. If we return Uninferable for all empty-body functions, it breaks tests - specifically getattr(int, 'missing_attr') returns Uninferable instead of raising InferenceError (test test_attribute_missing fails). Regarding pass/... - they create len(body) = 1, not 0. Only C builtins have truly empty bodies.

What I think I can simplify is to drop the self.root().qname() == "builtins" check since len(body) == 0 already guarantees it's a builtin. All tests pass (both in astroid and pylint) with this simplification. However, I'm not 100% certain there isn't some corner case we're missing with that removal. Let me know if you prefer me to drop this check πŸ˜„

Comment on lines 2360 to 2365
special_attr = self.special_attributes.lookup(name)
if not isinstance(
special_attr, (util.UninferableBase, node_classes.Unknown)
):
result = [special_attr]
return result
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the effect of this? What will we eventually return if the if is not True?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block is a short-circuit used when there are no concrete locals/ancestor definitions: originally it always returned special_attributes.lookup(name) (even if that was Unknown or Uninferable). The new behavior only returns the special_attr when it is a concrete value (not node_classes.Unknown or util.UninferableBase), preventing a placeholder from being returned prematurely and masking an override in a metaclass/base class. If the if is not true we continue the normal lookup (metaclass lookup, collect/filter locals/ancestors) and ultimately return any real definitions found - otherwise an AttributeInferenceError is raised. Placeholders (Unknown/Uninferable) therefore mean β€œkeep looking,” not β€œreturn this as the final result.”

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unkown feels like a nice placeholder for "keep looking", Uninferable generally means "stop inferring, you won't be able to". Can we make this only check for Unknown? Or does that not work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, good idea! ObjectModel only returns Unknown as placeholders, never Uninferable. I changed both checks (in ClassDef.getattr() and BaseInstance.getattr()) to only check for Unknown. If Uninferable somehow appeared from special_attributes, we should treat it as a final value and return it, not skip it. All tests pass with this change.

except AttributeInferenceError as exc:
if self.special_attributes and name in self.special_attributes:
return [self.special_attributes.lookup(name)]
special_attr = self.special_attributes.lookup(name)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar question as above

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same pattern as the ClassDef.getattr() check - when special_attr is Unknown or Uninferable, we skip the early return and continue to the if lookupclass: block which searches the class for the attribute. This ensures we find actual implementations in classes rather than stopping at the Unknown placeholder.

Comment on lines 81 to 83
# Special case: __hash__ = None overrides ObjectModel for unhashable types.
if name == "__hash__" and value is None:
_attach_local_node(node, nodes.const_factory(value), name)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be the case for all attributes where value is None? Or only hash?

Copy link
Contributor Author

@emmanuel-ferdman emmanuel-ferdman Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe only for __hash__. In Python __hash__ = None has a special semantic meaning - it explicitly marks a type as unhashable and must override the inherited object.__hash__. Other attributes set to None do not carry this override semantics and should not implicitly replace special_attributes entries, because that could hide real implementations or metadata. So I belive we should have this special-case restricted to __hash__ only.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we can link that documentation here? It would prevent me from wondering why this special case is here the next time.

And thanks for the link, TIL about something I knew implicitly but now know where to find the documentation for that feature πŸ˜„ Appreciated!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea - added the documentation link to both places πŸ˜„

child = object_build_datadescriptor(node, member)
elif isinstance(member, tuple(node_classes.CONST_CLS)):
if alias in node.special_attributes:
# Special case: __hash__ = None overrides ObjectModel for unhashable types.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question as above

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reasoning as the previous hash question - hash = None is the only attribute where None has special semantic meaning in Python (marks unhashable types). Other None values don't need this override behavior.

Comment on lines +5621 to +5623
# Builtin dunder methods now return Uninferable instead of raising InferenceError
result = next(lenmeth.infer_call_result(None, None))
assert result is util.Uninferable
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I think the changes above now make more sense to me.

pylint works correctly with this change? Thanks for testing that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - pylint works correctly (verified by running pylint test suite with astroid changes). pylint handles Uninferable in two ways: (1) explicit isinstance(value, util.UninferableBase) checks (e.g., pylint/checkers/typecheck.py:1365-1369, and (2) Uninferable is falsy like None, so if inferred: checks skip both. The behavior is identical - when builtin dunders can't be inferred, pylint skips type checking whether it gets None from an exception or Uninferable from the return value.

)
inferred = next(eq_result.infer())
assert isinstance(inferred, nodes.Const)
assert inferred.value == "custom equality"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@emmanuel-ferdman emmanuel-ferdman changed the title Add missing operators in FunctionModel Move object dunders from FunctionModel to ObjectModel Oct 7, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Enhancement ✨ Improvement to a component
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add __le__, __ge__ and other missing attributes to FunctionModel and ObjectModel
3 participants